![]() |
![]() |
|
Betrachten Sie zur Illustration ein einfaches Beispiel in C++. Ohne Exceptions ist dieser C++-Code korrekt: MeinObjekt* pMeinObjekt = new MeinObjekt();
meineOperation(pMeinObjekt);
delete pMeinObjekt;
Wenn allerdings meineOperation(pMeinObjekt) eine Exception wirft, haben Sie ein Speicherleck vorliegen, weil der Speicher, auf den pMeinObjekt verweist, nie freigegeben wird. Weil der normale Kontrollfluss bei Auftreten einer Exception unterbrochen wird, wird der Code zum Freigeben von pMeinObjekt in diesem Fall nicht durchlaufen. In Sprachen, die eine automatische dynamische Speicherverwaltung (Garbage Collection) aufweisen, besteht das Problem bezüglich der Anlage von neuen Objekten nicht. Andere Ressourcen können aber durchaus belegt bleiben, wenn eine Exception auftritt. Freigabe von Ressourcen Deshalb ist es in Programmen, die mit Exceptions arbeiten, meist notwendig, die Freigabe von Ressourcen explizit zu behandeln und dies auch so abzusichern, dass diese Freigabe auch im Exception-Fall erfolgt. Werden Invarianten innerhalb einer Methode zeitweise verletzt, muss das Gelten der Invariante beim Auftreten einer Exception wieder hergestellt werden. Es ist in diesen Fällen möglich, dass wir die Betrachtung von Exceptions durch diese Randbedingungen doch wieder in Methoden einfügen müssen, die weder mit dem Auslösen noch mit dem eigentlichen Behandeln der Exception etwas zu tun haben. Programme exception-sicher gestalten Was ist nun konkret zu tun, um Programme exception-sicher zu gestalten? Betrachten wir dazu zwei Beispiele in Java und C++. Wir beginnen mit der Programmiersprache Java und verwenden dazu eine modifizierte Variante eines Beispiels aus Abschnitt 4.1. Dieses beschäftigt sich mit elektrischen Leitungen und den idealisierten Annahmen, die sich mit dem Verhältnis von Stromstärke, Spannung und Wiederstand beschäftigen.
Nehmen wir an, Sie haben sich für die in Abbildung 7.60 gezeigte Umsetzung entschieden: Die drei Attribute Spannung, Widerstand und Stromstärke haben Sie jeweils als Datenelemente voltage, resistance und current umgesetzt. Bei jedem Zugriff von außen muss dann die angegebene Invariante greifen: U = R * I, hier also voltage = resistance * current. Wird der Wert für die Spannung geändert, möchten Sie diese Änderung in einer Datei mitprotokollieren. Die Umsetzung der Operation setVoltage muss in diesem Fall exception-sicher erfolgen. Listing 7.49 zeigt eine mögliche Umsetzung in Java. void setVoltage(Double voltage)
throws IOException {
FileOutputStream out = null;
try {
this.voltage = voltage;
// Die Invariante U = R * I gilt nicht mehr
out = new FileOutputStream( 1
"C:/logs/trace.txt"); 1
// Hier ist eine Datei geöffnet
PrintStream p = new PrintStream(out); 1
p.println("Setting voltage to " + voltage); 1
} finally { 2
if (out != null) {
out.close();
}
this.current = this.voltage / this.resistance;
}
}
Listing 7.49 Java: Exception-sichere Umsetzung von setVoltage Die Methode verwendet in den mit 1 markierten Zeilen Exemplare der Klassen FileOutputStream und PrintStream, um eine Protokollierung zu schreiben. Bei dieser Verwendung können Exceptions auftreten. Diese werden allerdings nicht behandelt, sondern die Behandlung bleibt anderen Aufrufebenen überlassen. Trotzdem muss die Methode dafür sorgen, dass im Fall einer Exception korrekt aufgeräumt wird. Dies geschieht im so genannten finally-Block, der in Zeile 2 umgesetzt ist. Der dort enthaltene Code wird in jedem Fall durchlaufen, auch wenn im davor aufgeführten try-Block eine Exception auftritt. In unserem Beispiel werden dort zwei verschiedene Aktionen durchgeführt. Zum einen wird die möglicherweise bereits geöffnete Datei auf jeden Fall geschlossen. Wäre das nicht der Fall, würden die entsprechende Datei und die damit verbundenen Ressourcen nicht mehr freigegeben. Zum anderen wird die Stromstärke auf jeden Fall auf den Wert gesetzt, welcher der Invariante entspricht. Wenn dies nämlich nicht im finally-Block stattfindet, kann eine Exception dazu führen, dass die für das Objekt definierte Invariante verletzt wird: Die Spannung ist bereits neu gesetzt, die resultierende Stromstärke hat aber weiter den alten Wert. Die Invariante inv: getVoltage() = getResistance() * getCurrent() gilt dann nicht mehr, und das Objekt würde den geschlossenen Kontrakt verletzen. In Sprachen wie C++ können Objekte auf dem Stack angelegt und dann beim Verlassen des Sichtbarkeitsbereichs automatisch destruiert werden. In diesen Sprachen kann zur Herstellung von Exception Safety ein Mechanismus verwendet werden, der unter dem Namen Ressourcenbelegung ist Initialisierung bekannt geworden ist. RAII: Ressourcenbelegung ist Initialisierung.
In Abbildung 7.61 ist ein Beispiel aufgeführt, in dem eine Klasse RAII (für Resource Acquisition is Initialisation) explizit eine Ressource verwaltet.
Betrachten Sie die zugehörige Umsetzung von Konstruktor und Destruktor in Listing 7.50, das eine Umsetzung in C++ aufführt. Dabei wird deutlich, dass die zugehörige Ressource im Konstruktor komplett angelegt und reserviert wird, im Destruktor wird die Ressource dann wieder freigegeben. RAII::RAII()
{
pMyResource = new MyResource();
pMyResource->acquire();
}
RAII::~RAII()
{
pMyResource->release();
delete pMyResource;
}
Listing 7.50 Verwaltung von Ressourcen in Konstruktor und Destruktor Die Verwendung des absichernden Objekts ist in Listing 7.51 dargestellt. void RAIITester::RunTest()
{
RAII raii;
bool condition_red = false;
MyResource* pResource = raii.GetResource();
// ... Aktionen mit der Ressource ausführen
if (condition_red) {
throw std::exception();
}
// .. weitere Aktionen
}
Listing 7.51 RAII in Verwendung Dabei wird eine lokale Variable für ein Exemplar von RAII angelegt, der dabei implizit aufgerufene Konstruktor sorgt dafür, dass die benötigte Ressource reserviert wird. Sobald der Sichtbarkeitsbereich von RunTest verlassen wird, wird der Destruktor von RAII aufgerufen, der dann die belegte Ressource in jedem Fall freigibt. Dies gilt auch in dem Fall, dass während des Ablaufs im Code eine Exception auftritt. Vorteile von Exceptions überwiegen Auch wenn Exceptions also die Notwendigkeit mit sich bringen, Code exception-sicher zu gestalten, überwiegen doch die Vorteile ihres Einsatzes. Weil Exceptions bestimmte Ausführungspfade verstecken, erhöhen sie die Übersichtlichkeit von Quelltexten, weil sie die wichtigen Abläufe klar erkennbar machen. Es bietet sich eine Analogie zur Verwendung von Polymorphie an: Aus dem Quelltext einer Methode, die eine Operation aufruft, können Sie nicht erkennen, welche konkrete Implementierung der Operation aufgerufen wird. Diese Information ist nicht offensichtlich, sie ist versteckt. Trotzdem, nein, gerade deswegen erhöht der Einsatz von virtuellen Methoden die Übersichtlichkeit des Quelltextes. Auch Exceptions erhöhen die Übersichtlichkeit und Wartbarkeit von Quelltexten, indem sie eine Reihe von Ausführungspfaden vor dem Programmierer verstecken. 7.6.3 Exceptions im Einsatz bei Kontraktverletzungen
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Tote Programme lügen nicht |
|
Wird innerhalb eines Programms durch Überprüfung von Kontrakten zur Laufzeit eine Kontraktverletzung festgestellt, ist für die weitere Ausführung des Programms die korrekte Funktion nicht mehr gewährleistet. Eine Faustregel, wie das Programm sich in solchen Situationen verhalten soll, formulieren die Pragmatiker Andy Hunt und Dave Thomas im Buch The Pragmatic Programmer ([Hunt 1999]): Dead programs tell no lies. Tote Programme lügen nicht. Diese Regel besagt, dass es oft besser ist, ein Programm zu beenden, das sich in einen undefinierten Zustand begeben hat. Damit wird verhindert, dass zum Beispiel die Datenbank durcheinander gebracht wird oder ein Patient eine falsche Dosis Strahlung erhält oder andere noch schlimmere Effekte entstehen. Da das Programm sich bei einer Kontraktverletzung nicht mehr in einem definierten Zustand befindet, ist theoretisch jeder Effekt möglich. |
Durch ein komplettes Beenden wird erreicht, dass ein Programm nicht in einem undefinierten Zustand weiterläuft. Dadurch werden mögliche Folgeschäden vermieden. Das Programm soll außerdem wieder in einen definierten Zustand gebracht werden. Und Neustart ist eine ziemlich sichere Methode, wie man in einen definierten Zustand zurückfinden kann.
Diskussion: Ist Neustart grundsätzlich besser?
Gregor: Ist es für ein Serversystem nicht meistens besser, wenn es weiterläuft? Nicht jede Kontraktverletzung führt automatisch zu dramatischen Inkonsistenzen. Und wenn sich zum Beispiel ein Datenbankserver beendet, weil er in einer einzelnen Aktion eine Kontraktverletzung entdeckt hat, ist es doch reichlich unverhältnismäßig, den gesamten Server zu beenden. Die Folgen des Beendens könnten doch wesentlich kritischer sein: Möglicherweise ist eine ganze Reihe von Applikationen längere Zeit nicht verfügbar, und es entstehen hohe Kosten.
Bernhard: Das kann in der Praxis richtig sein. Dennoch bleibe ich dabei, dass bei einer Kontraktverletzung in der Regel ein Neustart des betroffenen Programms die korrekte Lösung ist. In deinem Beispiel ist aber eher die Frage, was das betroffene Programm oder der betroffene Programmteil ist. Wenn ein Fehler in einem Bereich auftritt, der nur Aktionen für genau einen angemeldeten Benutzer der Datenbank ausführt, dann reicht es, genau diesen Teil zu beenden und neu zu starten. Es wäre wirklich weit über das Ziel hinausgeschossen, wenn dann in jedem Fall die Datenbank heruntergefahren wird.
Gregor: Und wenn die Kontraktverletzung in einem zentralen Teil des Datenbankservers festgestellt wird? Zum Beispiel beim Schreiben von Daten aus dem Arbeitsspeicher auf die Festplatte?
Bernhard: In diesem Fall sollte wahrscheinlich sogar der komplette Server beendet werden, weil die Datenbank möglicherweise ihre zentralen Konsistenzbedingungen nicht mehr einhalten kann. In den meisten Fällen wird es dann besser sein, den Server neu zu starten, anstatt möglicherweise inkonsistente Daten zu schreiben.
Im Fall einer Kontraktverletzung muss also ein Teil der auf einem Rechner laufenden Software beendet und neu gestartet werden, um in einen definierten Zustand zurückzukehren. Welcher Teil das ist, hängt davon ab, wie stark ein Programmteil von den anderen Teilen eines Programms isoliert ist. Wenn ein Fehler in einem Programmteil andere Teile nicht beeinträchtigen kann, so müssen diese auch nicht durchgestartet werden. Wenn auf einem Webserver ein Servlet ausgeführt wird und in diesem tritt eine Kontraktverletzung auf, so ist es nicht notwendig, den Webserver durchzustarten, es wird ausreichen, die Verbindung für den aktuell angemeldeten Anwender zurückzusetzen.
Aber wie wird eigentlich ein Programm am besten beendet, wenn eine Kontraktverletzung festgestellt wurde? Exceptions bieten hier eine Möglichkeit, Programme in definierter Weise zu beenden.
Exception bei erkannten Programmierfehlern
Im Fall eines Programmierfehlers sollte eine Exception geworfen werden, die signalisiert, dass eine Kontraktverletzung aufgetreten ist und dass ein Programmteil durchgestartet werden muss. Durch die Verwendung einer Exception ist es auch möglich, auf verschiedenen Ebenen des Programms notwendige Aufräumarbeiten durchzuführen, bevor das Programm beendet wird. So können zum Beispiel vom Programm angelegte temporäre Dateien noch gelöscht werden.
Wenn der Programmteil in einer Umgebung eingesetzt wird, in der ein kompletter Neustart nicht notwendig ist, kann die Exception auch gefangen werden, um dann nur den Programmteil neu zu starten, in dem die Exception aufgetreten ist. Die Verwendung von Exceptions überlässt die Entscheidung, welcher Teil neu gestartet werden muss, den aufrufenden Stellen.
Alternativ: Direkter Abbruch des Programms
Zur Illustration dieser Vorteile betrachten wir die zur Verfügung stehende Alternative. Die meisten Programmiersprachen bieten auch die Möglichkeit, unmittelbar das Beenden eines Programms auszulösen. In Java könnten Sie System.exit aufrufen, in C++ kann der Aufruf von abort oder exit mit einem Fehlercode als Parameter verwendet werden. Dadurch wird das Programm direkt beendet, eine Behandlung von Exceptions kann nicht mehr stattfinden.
Der einzige Vorteil dieser Vorgehensweise ist es, dass das Programm keinen weiteren Schaden mehr anrichten kann, weil es einfach direkt und unmittelbar beendet wird. Der sofortige Abbruch gleicht einem ehrenwerten Samurai, der sich seiner Unwürdigkeit bewusst wird und sich so für ein Seppuku entschließt. Kein gut gemeinter catch-Block, der alle Exceptions fängt, kann ihn dazu bringen, in einem undefinierten Zustand weiterzumachen und so möglicherweise seinem Meister noch mehr Schaden zuzufügen.
Nachteile des sofortigen Abbruchs
Aber die Nachteile des unmittelbaren Abbruchs sind offensichtlich. Bei einem sofortigen Abbruch kann die Anwendung notwendige Aufräumarbeiten nicht mehr erledigen. Den belegten Speicherplatz gibt das Betriebssystem frei, es löscht auch die Locks an geöffneten Dateien. Wer löscht aber die temporären Dateien? Wer benachrichtigt den Webserver, dass die Session beendet ist? Eine geworfene Exception erlaubt der Anwendung einen geordneten Rückzug, indem sie vor ihrer Wiedergeburt den Frieden mit der Welt schließen kann.
Ein weiterer Nachteil des sofortigen Abbruchs ist, dass er die Modularität des Programms verschlechtert. Eine Prozedur braucht nichts über das Programm zu wissen, in dem sie verwendet wird. Und wenn sie dieses Wissen nicht braucht, soll sie es auch nicht haben. Eine Prozedur, in der ein Programmierfehler festgestellt wurde, soll also nicht wissen, dass es in diesem Fall unsere Absicht ist, die Anwendung zu beenden. Vielleicht wird sie irgendwann in einer Anwendung verwendet, die ihre Teile besser isoliert und nur die Teile neu starten muss, in denen der Fehler aufgetreten ist. Wenn der sofortige Abbruch ausgelöst wird, sind solche Anpassungen nicht mehr möglich3 .
Fangen aller Exceptions?
| Achtung Code Smell: catch(...) oder catch (Throwable) |
|
Programmiersprachen, die Exceptions unterstützen, bieten in der Regel auch einen Mechanismus, um alle potenziell auftretenden Exceptions zu fangen. In C++ steht dafür das Statement catch(...) zur Verfügung, in Java kann die Basisklasse aller Exceptions, Throwable, verwendet werden. Damit besteht auch die Möglichkeit, für bestimmte Programmteile jegliche Exception ohne Ansehen der konkreten Klassenzugehörigkeit einfach zu fangen und dann im Programmablauf weiterzumachen. Dieses Vorgehen ist ein starkes Indiz für problematischen Code, einen so genannten Code Smell37. Ein catch-Block dieser Art sollte entweder dafür sorgen, dass das Programm beendet wird, oder die Exception weiterwerfen, damit ein anderer Programmteil das erledigen kann. Einfach die Exception zu protokollieren und weiterzumachen führt dazu, dass auftretende Fehler und Kontraktverletzungen ignoriert werden. Die daraus resultierenden Folgefehler können wesentlich schwerwiegender und vor allem schwieriger zu finden sein. Der unten stehende Java-Code »müffelt« also ziemlich stark: try { // ... verschiedene Aktionen } catch (Throwable t) { System.out.println(t.toString()); }Und auch der entsprechende C++-Code riecht nicht besser: try { // ... verschiedene Aktionen } catch (...) { cout << "Non recoverable unexpected error"; } |
Beide Code-Stücke enthalten das Problem, dass sie alle möglichen Fehlerarten abfangen, diese aber weder behandeln noch die gefangene Exception weiterwerfen.
In Abschnitt 7.5.1, Überprüfung von Kontrakten, haben Sie gesehen, wie Kontrakte zwischen Klassen und Objekten formuliert werden können. Dabei werden unter anderem für den Aufruf von Operationen Vorbedingungen und Nachbedingungen festgelegt. Die Einhaltung der Vorbedingungen muss dabei durch den Aufrufer sichergestellt werden. Wenn diese eingehalten sind, sichert ein Objekt zu, dass anschließend die Nachbedingungen gelten.
Nun, auch wenn der Aufrufer seinen Verpflichtungen nachgekommen ist und die Methode fehlerfrei implementiert wurde, kann es passieren, dass sie ihre Aufgabe nicht erledigen kann und scheitert. Damit müssen Sie bei jeder Methode rechnen, die Ressourcen nutzt, die außerhalb der Kontrolle des Programms stehen. Das Scheitern kann die Methode in diesem Fall dem Aufrufer durch das Werfen einer Exception signalisieren.
Kontrakte formulieren
Wie aber lassen sich die Kontrakte, die das Verhalten bei einer Exception beschreiben, formulieren und formalisieren?
Zunächst müssen wir hierfür klar machen, dass es ganz unterschiedliche Fehlersituationen sind, die in einem Programm entstehen können. Dabei sind zwei grundsätzliche Kategorien zu unterscheiden: Kontraktverletzungen durch Programmierfehler auf der einen Seite und bekannte Fehlersituationen, mit denen unser Programm umgehen kann, auf der anderen Seite.
Kontraktverletzungen durch Programmierfehler
| Kontraktverletzungen durch Programmierfehler |
|
Ein Programmierfehler entsteht dadurch, dass sich Methoden oder die Aufrufer von Operationen nicht entsprechend den Kontrakten verhalten, die für sie gelten. Wenn beim Ablauf einer Methode ein Programmierfehler festgestellt wird, kann das verschiedene Ursachen haben: Die Methode stellt fest, dass der Aufrufer sich nicht an seine Verpflichtungen aus dem Kontrakt hält. In diesem Fall liegt ein Programmierfehler bezüglich des Aufrufs der Operation vor. Die Methode stellt fest, dass die Umsetzungen der Operationen, die sie ihrerseits aufruft, sich nicht an deren Kontrakt halten. Es handelt sich also um einen Programmierfehler in den anderen Methoden. Die Methode selbst enthält einen Programmierfehler. Das Programm ist in einem inkonsistenten Zustand. Im Sinn eines Kontrakts heißt das, eine Invariante gilt zum aktuellen Zeitpunkt nicht. |
Bei anderen Fehlern ist es aber schon bekannt, dass sie unter bestimmten Umständen auftreten können. Ein Programm muss mit diesen Fehlersituationen umgehen können.
Bekannte Fehlersituationen
| Bekannte Fehlersituationen |
|
Bekannte Fehlersituationen sind solche, deren Behandlung im Programm vorgesehen ist. Beispiele für solche Fehler: Die externen Ressourcen, welche die Methode verwendet, stehen nicht zur Verfügung. Zum Beispiel kann eine Datei nicht geöffnet werden. |
| Die Parameter beim Aufruf der Methode, obwohl sie den Bedingungen des Kontraktes entsprechen, können nicht verarbeitet werden. Ein Beispiel ist der Versuch, einen Eintrag in eine Tabelle einzufügen, dessen Primärschlüssel bereits belegt ist. Die Operationen, welche die Methode aufruft, scheitern mit einer Exception, und die Methode selbst kann ohne die Ergebnisse der anderen Methoden ihre Aufgabe nicht erfüllen. |
Es wäre ziemlich widersinnig, Kontrakte zwischen dem Aufrufer und der aufgerufenen Operation zu spezifizieren, die sich mit Programmierfehlern befassen. Die Kontrakte sollen uns grade helfen, Programmierfehler zu vermeiden, also sollte es unser Ziel sein, dass solche Programmierfehler in einem fertigen Programm nicht mehr auftauchen. Wenn uns die Umsetzung einer Operation beschreiben würde, dass sie aufgrund eines bestimmten Umsetzungsfehlers in manchen Situationen die Exception ProgrammingErrorException wirft, würden wir dem zuständigen Programmierer mit gutem Recht sagen können: Dann beheb doch einfach den Fehler, anstatt in diesem Fall eine Exception zu werfen.
Eine Methode kann und braucht also nicht zu versprechen, dass sie nie wegen eines Programmierfehlers scheitert. Einerseits ist dieses Versprechen sowieso immer implizit gegeben, anderseits dürfen Sie dem Versprechen nie glauben.
Kontrakt bezüglich Exception
Bei den als möglich bekannten Fehlern und den daraus resultierenden Exceptions sieht es anders aus. Eine Operation kann in zweierlei Hinsicht einen Kontrakt bezüglich Exceptions formulieren. Zum einen kann sie zusichern, dass sie in bestimmten Fehlersituationen eine ganz bestimmte Exception wirft. Zum anderen kann sie auch zusichern, dass sie bestimmte Exceptions unter gar keinen Umständen werfen wird. Im letzteren Fall hat ein Aufrufer den Vorteil, dass er sich um diese Exceptions auch auf keinen Fall kümmern muss.
Beide Informationen können über eine Liste von Exception-Klassen angegeben werden, die von einer Operation ausgelöst werden können. Diese Liste wird über eine so genannte throws-Klausel einer Operation zugeordnet.
Findet für bestimmte Klassen von Exceptions eine Überprüfung dieses Kontrakts durch den Compiler statt, werden diese in Anlehnung an die Java-Terminologie als Checked Exceptions bezeichnet.4
| Checked Exceptions (überprüfte Exceptions) |
|
Als Checked Exceptions38 werden solche Exception-Klassen bezeichnet, für die bereits zur Übersetzungszeit eines Programms Prüfungen stattfinden, die eine Behandlung der Exception erzwingen. Wird innerhalb einer Methode, welche die Operation myOperation umsetzt, eine Exception vom Typ der Klasse CheckedException geworfen, so muss diese entweder innerhalb der Methode wieder gefangen werden oder die Methode muss explizit deklarieren, dass sie diese Exception wirft. Deklariert die Operation myOperation, dass sie eine Exception vom Typ CheckedException wirft, so muss jede Methode, welche die Operation aufruft, diese Exception entweder fangen oder ebenfalls deklarieren, dass diese Exception geworfen wird. |
Eine Methode, die eine Checked Exception in ihrer throws-Klausel nicht aufführt, sichert damit zu, dass diese Checked Exception von ihr nie geworfen wird. Somit ist der Aufrufer von der Notwendigkeit befreit, solche Exceptions zu behandeln.
Eine Operation kann im Rahmen des für sie gültigen Kontrakts versprechen, dass sie bestimmte Checked Exceptions nicht wirft. Sie tut es, indem sie diese Exceptions (oder ihre Oberklassen) nicht in ihrer throws-Klausel angibt. Will oder kann eine Methode so eine Verpflichtung nicht übernehmen, muss sie alle Checked Exception-Klassen, die sie werfen möchte, in der throws-Klausel aufzählen.
Checked Exceptions und Java
Betrachten wir ein einfaches Beispiel in der Programmiersprache Java, bei dem Checked Exceptions zum Einsatz kommen. In der Exception-Hierarchie von Java sind alle Exception-Klassen checked. Eine Ausnahme sind die Klasse RuntimeException und ihre Unterklassen. In Listing 7.52 ist eine Situation dargestellt, in der ein Java-Compiler einen Fehler signalisieren würde.
class MyCheckedException extends Exception { 1
}
public class CheckedExceptionExample {
void eineOperation() {
kritischeOperation();
}
void kritischeOperation() { 2
// ...
if (!aktionIstMoeglich()) {
throw new MyCheckedException(); 3
}
// ...
}
private boolean aktionIstMoeglich() {
return false;
}
}
Listing 7.52 Fehlerhafter Code mit Checked Exception
In Zeile 1 wird eine neue Exception-Klasse deklariert. Als Unterklasse von Exception handelt es sich um eine Checked Exception. Innerhalb der Methode kritischeOperation in Zeile 2 kann es dazu kommen, dass eine solche Exception geworfen wird (Zeile 3). Ein Java-Compiler wird für diesen Code die Meldung generieren "Unhandled exception type MyCheckedException". Die Methode kritischeOperation muss nämlich entweder die Exception fangen oder die Exception-Klasse in ihrer throws-Klausel angeben. In Abbildung 7.62 ist zu sehen, dass zum Beispiel die Entwicklungsumgebung Eclipse in diesem Fall genau die beiden genannten Möglichkeiten zur Korrektur vorschlägt.

Hier klicken, um das Bild zu vergrößern
Wenn Sie die Exception nicht direkt behandeln können, ist also die Erweiterung der throws-Klausel die einzige Alternative:
void kritischeOperation() throws MyCheckedException {
// ...
Im Fall unseres Beispiels verlagert dies allerdings nur das Problem, da nun der Aufruf aus eineOperation heraus nicht mehr zulässig ist. eineOperation ruft nämlich kritischeOperation auf. Damit muss auch hier die Exception entweder gefangen oder die throws-Klausel angepasst werden:
void eineOperation() throws MyCheckedException {
kritischeOperation();
}
Mit dieser Anpassung haben Sie die Aufgabe, die Exception zu behandeln, an die jeweiligen Aufrufer von eineOperation delegiert.
Keine Checked Exceptions in C#
Der Mechanismus von Checked Exceptions wird in Java sehr intensiv genutzt. Bei anderen Sprachen wie zum Beispiel C# haben sich die Sprachdesigner explizit dagegen entschieden, diesen Mechanismus aufzunehmen.5 Obwohl der Mechanismus der Checked Exceptions auf den ersten Blick sehr vernünftig aussieht, verursacht er in der Praxis oft mehr Probleme, als er löst.
Eigentlich handelt es sich ja um eine einfache Idee: Es wird lediglich verlangt, dass eine Methode eine Exception entweder behandelt oder signalisiert, dass sie eine Behandlung der Exception nicht zusichern kann und das der Aufrufer tun muss.
In den folgenden Abschnitten stellen wir deshalb an Java-Beispielen vor, auf welche Arten Checked Exceptions dort behandelt werden können und zu welchen Problemen das jeweilige Vorgehen führt. Dennoch müssen Sie gerade in Java mit den Checked Exceptions umgehen. Es ist dabei aber in der Praxis oft besser, die Checked Exceptions in andere Exceptions einzubetten, die selbst nicht überprüft werden.
Wenn Sie in einer Java-Methode eine Operation aufrufen, die in ihrer throws-Klausel eine Checked Exception aufführt, müssen Sie in Ihrer Methode mit dieser Exception umgehen können. Ein Java-Compiler wird es Ihnen nicht erlauben, die benötigte Operation aufzurufen, wenn Sie nicht eine adäquate Behandlung der Exception vornehmen.
Es gibt nun abhängig von der Art des Aufrufs und der Art der Exception verschiedene Möglichkeiten, was Sie tun können. Wenn Sie die Exception in Ihrer Methode so behandeln können, dass Sie trotz der Exception normal weiterarbeiten können, sind Sie natürlich aus dem Schneider. Sie können die Exception einfach fangen und dann weitermachen. Oft ist das aber nicht der Fall, und die Exception muss in irgendeiner Form weitergereicht werden. Bei Checked Exceptions bleiben Ihnen dann drei Möglichkeiten:
1. Sie erweitern die throws-Klausel der Methode, so dass die Checked Exception darin enthalten ist. 2. Sie fangen die Exception und übersetzen sie in eine eigene Checked Exception. 3. Sie fangen die Exception und überführen sie in eine Exception, die nicht überprüft wird, eine Unchecked Exception.In den folgenden Abschnitten betrachten wir jeweils kurz die beschriebenen Möglichkeiten an Beispielen.
Die einfachste und schnellste Lösung, um mit einer Checked Exception umzugehen, ist die Erweiterung der eigenen throws-Klausel. Wenn der Aufrufer die Exception nicht behandeln kann, führt diese Anpassung dazu, dass er die benötigte Operation nun aufrufen kann.
Obwohl diese Vorgehensweise die einfachste ist, ist sie nicht ohne Probleme. Damit reichen Sie nämlich die internen Abhängigkeiten der Methodenimplementierung einfach weiter. Sie verlagern die Verantwortung, mit der Exception umzugehen, auf Ihre eigenen Aufrufer. Und da sich eine solche Abhängigkeit nicht aus der Spezifikation einer Operation ergibt, sondern aus der konkreten gewählten Umsetzung, wird die Art der Umsetzung relevant für die Schnittstelle. Wenn Sie die Implementierung später noch einmal ändern und eine andere Operation aufrufen, die wieder eine andere Checked Exception wirft, wären alle Ihre Aufrufer betroffen, wenn Sie diese einfach weiterreichen.
Betrachten Sie dazu das Java-Beispiel aus Listing 7.53. Die dort aufgeführte Klasse CustomerProvider benutzt JDBC, um den Zugriff auf eine Datenbank zu realisieren. Die dabei genutzte Operation executeQuery in Zeile 1 enthält in ihrer throws-Klausel die Klasse SQLException. Diese gehört in Java zu den Checked Exceptions. Damit muss auch die Methode getCustomers die Klasse in ihrer Liste führen, es resultiert die throws-Klausel in Zeile 2.
public class CustomerFilter {
public Customer getBestCustomer(CustomerProvider provider)
throws SQLException { 3
Collection<Customer> customers = provider.getCustomers();
// ... weitere Aktionen
}
}
public class CustomerProvider {
...
public Collection<Customer> getCustomers()
throws SQLException { 2
ResultSet rs = connection.executeQuery(...);1
// ... weitere Aktionen
}
}
Listing 7.53 Operationen mit Checked Exceptions
Die Klasse CustomerFilter, deren Methode getBestCustomer den besten Kunden aussuchen soll, benutzt ein Exemplar von CustomerProvider, das sie als Parameter bekommt, um an die Kundenliste zu kommen. Obwohl CustomerFilter in keinerlei eigener Abhängigkeit zu JDBC steht, muss sie entweder die SQLException behandeln, oder sie muss sie, wie in unserem Beispiel in Zeile 3, selbst in der throws-Klausel deklarieren. In Abbildung 7.63 sind die entstehenden Abhängigkeiten aufgeführt.

Hier klicken, um das Bild zu vergrößern
Auch die Klasse CustomerProvider weist nun eine Abhängigkeit zu SQLException und damit zu JDBC auf. Das ist unangenehm, denn hier vermischen wir die Domäne der Kundenverwaltung mit der Domäne der JDBC-basierten Datenhaltung. Das fachliche Anliegen ist nicht mehr klar von den technischen Anliegen getrennt. Das läuft dem Prinzip der Trennung der Anliegen zuwider.
Die Option, Checked Exception einfach in die throws-Klausel zu übernehmen, verlieren wir also, wenn die Domäne, die wir für die Implementierung einer Methode betreten, außerhalb der Domäne der Aufgabe liegt, die wir zu erfüllen haben. In unserem Beispiel benutzen wir die Methode executeQuery, die in dem Bereich der JDBC-Datenhaltung liegt. Die Aufgabe der Methode getCustomers liegt aber in dem Bereich Kundenverwaltung. Wir sollten den Quelltexten, die getCustomers verwenden, die Abhängigkeit zu SQLException und somit zu JDBC nicht aufzwingen. Eine Übernahme einer Exception in die eigene throws-Klausel ist also nur dann anzuraten, wenn die Exception in derselben Domäne liegt wie die Methode, die Sie umsetzen.
Eine Alternative zum einfachen Weiterreichen über die throws-Klausel ist die so genannte Exception Translation.
Die Methode getCustomers aus dem Beispiel in Listing 7.53 muss scheitern, wenn die verwendeten JDBC-Aufrufe scheitern. Wie Sie im vorigen Abschnitt gesehen haben, sollte getCustomers aber keine SQLException werfen. Sie kann allerdings eine Exception werfen, die der Domäne Kundenverwaltung zugeordnet ist. Um dies zu verdeutlichen, haben wir in Abbildung 7.64 die Schnittstelle und die Implementierung klarer getrennt. Die Klasse CustomerProvider ist nun eine Schnittstelle, zu der eine JDBC-spezifische Implementierung vorliegt. Diese wird über die Klasse JdbcCustomerProvider realisiert. In der Abbildung ist die resultierende Klassenstruktur dargestellt.

Hier klicken, um das Bild zu vergrößern
Die Abhängigkeit von CustomerProvider und damit von CustomerFinder zur SQLException ist in dieser Variante beseitigt. Beide verwenden eine eigene Exception, die aus der Domäne Kundenverwaltung stammt, nämlich CustomerException.
Der angepasste Quelltext für die Umsetzung der Operation getCustomers ist in Listing 7.54 aufgeführt.
public class JdbcCustomerProvider implements CustomerProvider {
public Collection<Customer> getCustomers() throws ð
CustomerException {
try {
ResultSet rs = connection.executeQuery(...);
... usw. ...
} catch (SQLException sqle) { 1
throw new CustomerException("Datenbankproblem!"); 2
} finally {
// JDBC-Objekte schließen
... usw. ...
}
}
}
Listing 7.54 Exception Translation für SQLException
Eine auftretende SQLException wird in Zeile 1 gefangen und in Zeile 2 in eine CustomerException aus der eigenen Domäne übersetzt.
Eine andere, zum Beispiel webbasierte, Implementierung HttpCustomerProvider würde ihre internen Exceptions auch abfangen müssen und sie in CustomerExceptions umwandeln. Dies wird durch die Schnittstelle CustomerProvider erzwungen. Die Schnittstelle legt die Verpflichtung fest, keine anderen Checked Exceptions zu werfen als eine CustomerException. Eine Implementierung der Schnittstelle kann keine Verpflichtung, die durch die Schnittstelle übernommen wurde, ablehnen. Sie kann sich aber zu mehr verpflichten und ihre throws-Klausel leer lassen oder nur bestimmte Unterklassen von CustomerException angeben.
Diese Lösung der Exception Translation ist für viele Fälle anwendbar und ermöglicht es, eine Trennung zwischen unterschiedlichen Domänen auch in Bezug auf die Behandlung von Exceptions durchzuhalten. Allerdings wird der durch die Checked Exceptions geschlossene Kontrakt durch diesen Mechanismus häufig einfach umgangen.
Im nächsten Abschnitt werden wir erläutern, warum auch die Exception Translation problematisch und eine Lösung unter Verwendung von normalen Unchecked Exceptions vorteilhaft sein kann.
Im vorigen Abschnitt haben wir die Exception CustomerException in der Domäne Kundenverwaltung vorgestellt. Diese domänenspezifische Exception kann grundsätzlich zwei Ursachen haben.
Einerseits kann die Ursache tatsächlich in der Domäne Kundenverwaltung liegen. Ein Beispiel für eine solche Ursache wäre, wenn Sie einen Kunden anlegen möchten, der noch nicht 18 ist, und die Geschäftsbedingungen des Unternehmens lassen dies nicht zu. Eine Methode createCustomer würde in diesem Falle eine CustomerException werfen.
Auch wenn die Ursache in der Domäne Kundenverwaltung liegt, kann es trotzdem sein, dass der Fehler in einer anderen (technischen) Domäne festgestellt wird. Zum Beispiel kann ein Fehler beim Einfügen eines Datensatzes in der Datenbank bedeuten, dass eine Kundennummer bereits vergeben ist. In diesem Falle könnte die Methode createCustomer die SQLException abfangen und sie in eine CustomerException übersetzen.
Andererseits aber kann das Problem tatsächlich in der anderen, technischen Domäne liegen. Es kann sein, dass die Datenbank keinen Festplattenplatz mehr hat oder dass sie einfach überlastet ist oder dass der Datenbankserver gerade lichterloh brennt. Abbildung 7.65 zeigt einen solchen Fall.

Hier klicken, um das Bild zu vergrößern
Sie haben zwar die Schicht, in der die Datenhaltung geschieht, gekapselt und abstrahiert, aber Abstraktionen tendieren dazu, Lecks zu haben6 , und Probleme in der Schicht der Datenhaltung werden hin und wieder auch in den anderen Schichten als solche sichtbar werden.
Wenn der Datenbankserver brennt, lässt sich das kaum als eine sinnvolle Exception in der Domäne Kundenverwaltung ausdrücken. Sie könnten zwar die SQLException abfangen und eine nichts sagende CustomerException werfen. Damit hätten Sie aber das Leck in der Abstraktion nicht behoben, Sie hätten es nur verschleiert – um letztendlich dem Benutzer eine Fehlermeldung der Art »Ein unerwarteter Fehler ist aufgetreten. [OK] [Cancel] [Dankeschön]« zu präsentieren.
Damit berauben Sie den Benutzer der Chance, den tatsächlichen Fehler schnell zu identifizieren, ihn eventuell zu beheben und mit dem Feuerlöscher in den Serverraum zu rennen.
Wenn ein Fehler auftritt, den Sie nicht einer Exception, die tatsächlich in unserer Domäne liegt, zuordnen können, sollten Sie diese Tatsache nicht verschleiern. Wenn dieser Fehler durch eine Checked Exception signalisiert wird, können und müssen Sie diese zwar in eine andere Exception übersetzen, Sie sollten die ursprüngliche Exception dabei aber nicht komplett ersetzen, sondern sie zumindest in die neue Exception einbetten.
So gibt es zum Beispiel in Java Exceptions seit der Version 1.4 des JDK die Eigenschaft cause, die im Konstruktor gesetzt werden kann und genau diesem Zweck dient. Mit diesem Mechanismus können Sie die ursprüngliche Exception in eine neue Exception einbetten. Der angepasster Quelltext ist in Listing 7.55 zu sehen.
public class JdbcCustomerProvider implements CustomerProvider {
public Collection<Customer> getCustomers()
throws CustomerException {
try {
ResultSet rs = connection.executeQuery(...);
// ...
} catch (SQLException sqle) {
throw new CustomerException(
"Datenbankproblem!", sqle);
} finally {
// JDBC-Objekte schließen
// ...
}
}
}
Listing 7.55 Eingebettete Exception in Java
So weit, so gut. Sie werfen zwar eine CustomerException, es ist aber in Wirklichkeit keine. Tatsächlich ist es eine verschleierte SQLException, die Sie aber nicht direkt durchlassen dürfen, weil es der definierte Kontrakt verbietet.
Sie haben also einen Weg gefunden, den Kontrakt zwar formal zu erfüllen, tatsächlich umgehen Sie ihn aber. Nicht gerade ein Zeichen hoher Moral, aber was bleibt Ihnen anderes übrig? Das System zwingt Sie zum Mogeln. Wäre SQLException nicht checked, könnten Sie die Exception ganz offen durchlassen. So aber müssen sie diese in eine waschechte CustomerException umwandeln.
Den Kontrakt, der Sie dazu verpflichtet, keine SQLException zu werfen, gibt es aus zwei Gründen: Sie wollen Ihrem Aufrufer die Mühe ersparen, dass er sich mit JDBC befassen muss. Und Sie wollen die Quelltextabhängigkeiten des direkten Aufrufers zu JDBC vermeiden. Schließlich kann es sein, dass er sonst gar nichts mit JDCB zu tun hat, es gibt ja keine logischen Abhängigkeiten zu JDBC.
Die erste noble Absicht können Sie aber, wie sich gezeigt hat, leider nicht erfüllen. Die Abstraktionen haben Lecks, und Sie werden gezwungen, entweder die SQLException unbehandelt einfach wegzufischen und sie durch eine CustomerException zu ersetzen. Alternativ können Sie den Kontrakt auch beugen, indem Sie die SQLException Ihrer throws-Klausel hinzufügen und diese dann zum Aufrufer weiterreichen, wahrscheinlich noch weiter, bis zu einer Stelle, an der einem Benutzer dann die Exception angezeigt wird.
Die zweite Absicht ist erfüllbar, und sie ist auch sehr wichtig. In den Quelltexten der Kundenverwaltungsschicht sollten tatsächlich keine JDBC-Bezüge stehen, wenn sie nicht unvermeidbar sind. Diese Absicht ließe sich aber mit viel weniger Tipparbeit erledigen, wenn Sie die SQLException unchecked machen könnten.
Bei SQLException bleibt Ihnen nichts anderes übrig, aber wenn Sie eigene Exceptions definieren, spricht wenig dafür, diese als Checked Exceptions zu deklarieren.
Die SQLExceptions selbst ist checked, Sie können diese aber fangen und in eine Exception einbetten, die selbst nicht als checked deklariert ist. Dabei kann es sich je nach Bedarf der Anwendung um eine unspezifische SoftenedCheckedException oder um eine spezifische SoftenedSQLException handeln.
In den vorhergehenden Abschnitten haben Sie die verschiedenen Verwendungsmöglichkeiten von Exceptions kennen gelernt. In diesem Abschnitt finden Sie noch einmal eine kurze Zusammenfassung der vorgestellten Eigenschaften.
| Exceptions bieten einen etablierten und in vielen Fällen vorteilhaften Mechanismus zur Fehlerbehandlung. Sie verstecken die Pfade der Programmausführung im Fehlerfall und tragen so zur Übersichtlichkeit von Code bei. |
| Exceptions können verwendet werden, um Verletzungen von Kontrakten beim Aufruf einer Operation zu signalisieren. Als Reaktion auf die Kontraktverletzung ist es meist notwendig, das betroffene Programm oder einen Programmteil neu zu starten. Die Verwendung von Exceptions erlaubt es, vorher abschließende Aufgaben durchzuführen, so dass Aufräumarbeiten vor dem Beenden möglich sind. |
| Exceptions können auch selbst Teil des Kontrakts sein, der zwischen Aufrufer und Umsetzer einer Operation geschlossen wird. |
| Die Checked Exceptions in Java ermöglichen das formelle Deklarieren eines Kontraktes zwischen dem Aufrufer und der Methode, indem sich die Methode verpflichtet, bestimmte Exceptions nicht zu werfen. Der Vorteil für den Aufrufer ist, dass er sich um solche Checked Exceptions nicht kümmern muss. |
| Allerdings wird der Kontrakt in vielen Fällen nur formell eingehalten, und die ursprünglichen Checked Exceptions werden trotzdem geworfen, allerdings eingebettet in andere Checked oder Unchecked Exceptions. Die Verpflichtung des Kontraktes wird also häufig umgangen. |
| Der Aufrufer kann oft auf den Vorteil, den er aus einem solchen Kontrakt ziehen könnte, verzichten, weil er die Exception durchaus behandeln könnte, indem er einfach dem Benutzer eine Fehlermeldung anzeigt. |
1 Im Bereich der Ausnahmebehandlung hat sich die Verwendung der englischen Begriffe auch im Deutschen etabliert. Wir benutzen deshalb im Folgenden die englischen Begriffe und geben bei der ersten Verwendung eine deutsche Übersetzung an.
2 In Java muss eine Operation für Exceptions explizit deklarieren, dass diese geworfen werden können. Diesen Mechanismus, der Checked Exceptions genannt wird, beschreiben wir in Abschnitt 7.6.4, Exceptions als Teil eines Kontraktes. Eine Ausnahme bilden die Klasse RuntimeException und ihre Unterklassen, die wir deshalb in diesem Beispiel verwenden.
3 Code smell lässt sich etwa mit »müffelnder Code« übersetzen. Martin Fowler hat den Begriff geprägt für Code, bei dem irgendetwas nicht in Ordnung ist, obwohl er in den meisten Situationen trotzdem funktioniert.
4 Wir bleiben für den Bereich der Exceptions bei englischen Begriffen und werden im Folgenden von Checked Exceptions sprechen.
5 Anders Hejlsberg, der Chefarchitekt der Sprache C#, begründet in einem Gespräch mit Bill Venners (http://www.artima.com/intv/handcuffs.html), warum Checked Exceptions nicht in C# integriert wurden.
6 Diese These wird als Law of leaky Abstractions von Joel Spolsky vertreten: http://www.joelonsoftware.com/articles/LeakyAbstractions.html
| << zurück |
|
||||||||||||||
|
||||||||||||||
|
||||||||||||||
|
||||||||||||||
Copyright © Galileo Press 2006
Für Ihren privaten Gebrauch dürfen Sie die Online-Version natürlich ausdrucken. Ansonsten unterliegt das <openbook> denselben Bestimmungen, wie die gebundene Ausgabe: Das Werk einschließlich aller seiner Teile ist urheberrechtlich geschützt. Alle Rechte vorbehalten einschließlich der Vervielfältigung, Übersetzung, Mikroverfilmung sowie Einspeicherung und Verarbeitung in elektronischen Systemen.